自定义View(7) -- 酷狗侧滑菜单

效果图
上一篇我们自定义了一个流式布局的ViewGroup,我们为了熟悉自定义ViewGroup,就继续自定义ViewGroup。这篇的内容是是仿照酷狗的侧滑菜单。
我们写代码之前,先想清楚是怎么实现,解析实现的步骤。实现侧滑的方式很多种,在这里我选择继承HorizontalScrollView,为什么继承这个呢?因为继承这个的话,我们就不用写childViewmove meause layout,这样就节约了很大的代码量和事件,因为内部HorizontalScrollView已经封装好了。我们在这个控件里面放置两个childView,一个是menu,一个是content。然后我们处理拦截和快速滑动事件就可以了。思路想清楚了我们就开始撸码。
首先我们自定义一个属性,用于打开的时候content还有多少可以看到,也就是打开的时候menu距离右边的距离。

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SkiddingMenuLayout">
<attr name="menuRightMargin" format="dimension"/>
</declare-styleable>
</resources>

在初始化的时候我们通过menuRightMargin属性获取menu真正的宽度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public SkiddingMenuLayout(Context context) {
this(context, null);
}

public SkiddingMenuLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public SkiddingMenuLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);


// 初始化自定义属性
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.SkiddingMenuLayout);

float rightMargin = array.getDimension(
R.styleable.SkiddingMenuLayout_menuRightMargin, DisplayUtil.dip2px(context, 50));
// 菜单页的宽度是 = 屏幕的宽度 - 右边的一小部分距离(自定义属性)
mMenuWidth = (int) (DisplayUtil.getScreenWidth(context) - rightMargin);
array.recycle();
}

接着我们在布局加载完毕的时候我们指定menucontent的宽度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//xml 布局解析完毕回调的方法
@Override
protected void onFinishInflate() {
super.onFinishInflate();
//指定宽高
//先拿到整体容器
ViewGroup container = (ViewGroup) getChildAt(0);

int childCount = container.getChildCount();
if (childCount != 2)
throw new RuntimeException("只能放置两个子View");
//菜单
mMenuView = container.getChildAt(0);
ViewGroup.LayoutParams meauParams = mMenuView.getLayoutParams();
meauParams.width = mMenuWidth;
//7.0一下的不加这句代码是正常的 7.0以上的必须加
mMenuView.setLayoutParams(meauParams);

//内容页
mContentView = container.getChildAt(1);
ViewGroup.LayoutParams contentParams = mContentView.getLayoutParams();
contentParams.width = DisplayUtil.getScreenWidth(getContext());
//7.0一下的不加这句代码是正常的 7.0以上的必须加
mContentView.setLayoutParams(contentParams);
}

这里有一个细节,我们在刚进入的时候,菜单默认是关闭的,所以我们需要调用scrollTo()函数移动一下位置,但是发现在onFinishInflate()函数里面调用没有作用,这个是为什么呢?因为我们在xml加载完毕之后,才会真正的执行View的绘制流程,这时候调用scrollTo()这个函数其实是执行了代码的,但是在onLaout()摆放childView的时候,又默认回到了(0,0)位置,所以我们应该在onLayout()之后调用这个函数

1
2
3
4
5
6
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
//进入是关闭状态
scrollTo(mMenuWidth, 0);
}

初始化完毕了,接下来我们进行事件的拦截,MOVE的时候相应滑动事件,UP的时候判断是关闭还是打开,然后调用函数即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

//手指抬起是二选一,要么关闭要么打开
@Override
public boolean onTouchEvent(MotionEvent ev) {
// 当菜单打开的时候,手指触摸右边内容部分需要关闭菜单,还需要拦截事件(打开情况下点击内容页不会响应点击事件)
if (ev.getAction() == MotionEvent.ACTION_UP) {
// 只需要管手指抬起 ,根据我们当前滚动的距离来判断
int currentScrollX = getScrollX();
if (currentScrollX > mMenuWidth / 2) {
// 关闭
closeMenu();
} else {
// 打开
openMenu();
}
return true;
}
return super.onTouchEvent(ev);
}

/**
* 打开菜单 滚动到 0 的位置
*/
private void openMenu() {
// smoothScrollTo 有动画
smoothScrollTo(0, 0);
}

/**
* 关闭菜单 滚动到 mMenuWidth 的位置
*/
private void closeMenu() {
smoothScrollTo(mMenuWidth, 0);
}

到这的话,滑动事件和打开关闭事件都完成了,接下来我们就处理一个效果的问题,这里当从左往右滑动的时候,是慢慢打开菜单,这时候content是有一个慢慢的缩放,menu有一个放大和透明度变小,而反过来关闭菜单的话就是相反的效果,content慢慢放大,menu缩小和透明度变大。这里还有一个细节,就是menu慢慢的退出和进入,滑动的距离不是和移动的距离相同的,所以这里还有一个平移。接下来重写onScrollChanged()函数,然后计算出一个梯度值来做处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
 //滑动改变触发
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);

// //抽屉效果 两种一样
// ViewCompat.setTranslationX(mMenuView, l);
// ViewCompat.setX(mMenuView, l);

// Log.e("zzz", "l->" + l + " t->" + t + " oldl->" + oldl + " oldt->" + oldt);
//主要看l 手指从左往右滑动 由大变小
//计算一个梯度值 1->0
float scale = 1.0f * l / mMenuWidth;

//酷狗侧滑效果...
// //右边的缩放 最小是0.7f ,最大是1.0f
float rightScale = 0.7f + 0.3f * scale;
//设置mContentView缩放的中心点位置
ViewCompat.setPivotX(mContentView, 0);
ViewCompat.setPivotY(mContentView, mContentView.getHeight() / 2);
//设置右边缩放
ViewCompat.setScaleX(mContentView, rightScale);
ViewCompat.setScaleY(mContentView, rightScale);

//菜单
//透明度是半透明到全透明 0.5f-1.0f
float alpha = 0.5f + (1.0f - scale) * 0.5f;
ViewCompat.setAlpha(mMenuView, alpha);

//缩放 0.7-1.0
float leftScale = 0.7f + 0.3f * (1 - scale);
ViewCompat.setScaleX(mMenuView, leftScale);
ViewCompat.setScaleY(mMenuView, leftScale);

//退出按钮在右边
ViewCompat.setTranslationX(mMenuView, 0.2f * l);
}

这样的话我们就完成了效果,但是我们还有几个细节没有处理,首先是快速滑动的问题,还有一个是当打开menu的时候,点击content需要关闭菜单,而不是相应对应的事件。接下来我们对这两个问题进行处理。

快速滑动问题,这个问题我们采用GestureDetector这个类来做处理,这个类可以处理很多收拾问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128

/**
* The listener that is used to notify when gestures occur.
* If you want to listen for all the different gestures then implement
* this interface. If you only want to listen for a subset it might
* be easier to extend {@link SimpleOnGestureListener}.
*/
public interface OnGestureListener {

/**
* Notified when a tap occurs with the down {@link MotionEvent}
* that triggered it. This will be triggered immediately for
* every down event. All other events should be preceded by this.
*
* @param e The down motion event.
*/
boolean onDown(MotionEvent e);

/**
* The user has performed a down {@link MotionEvent} and not performed
* a move or up yet. This event is commonly used to provide visual
* feedback to the user to let them know that their action has been
* recognized i.e. highlight an element.
*
* @param e The down motion event
*/
void onShowPress(MotionEvent e);

/**
* Notified when a tap occurs with the up {@link MotionEvent}
* that triggered it.
*
* @param e The up motion event that completed the first tap
* @return true if the event is consumed, else false
*/
boolean onSingleTapUp(MotionEvent e);

/**
* Notified when a scroll occurs with the initial on down {@link MotionEvent} and the
* current move {@link MotionEvent}. The distance in x and y is also supplied for
* convenience.
*
* @param e1 The first down motion event that started the scrolling.
* @param e2 The move motion event that triggered the current onScroll.
* @param distanceX The distance along the X axis that has been scrolled since the last
* call to onScroll. This is NOT the distance between {@code e1}
* and {@code e2}.
* @param distanceY The distance along the Y axis that has been scrolled since the last
* call to onScroll. This is NOT the distance between {@code e1}
* and {@code e2}.
* @return true if the event is consumed, else false
*/
boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);

/**
* Notified when a long press occurs with the initial on down {@link MotionEvent}
* that trigged it.
*
* @param e The initial on down motion event that started the longpress.
*/
void onLongPress(MotionEvent e);

/**
* Notified of a fling event when it occurs with the initial on down {@link MotionEvent}
* and the matching up {@link MotionEvent}. The calculated velocity is supplied along
* the x and y axis in pixels per second.
*
* @param e1 The first down motion event that started the fling.
* @param e2 The move motion event that triggered the current onFling.
* @param velocityX The velocity of this fling measured in pixels per second
* along the x axis.
* @param velocityY The velocity of this fling measured in pixels per second
* along the y axis.
* @return true if the event is consumed, else false
*/
boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
}

/**
* The listener that is used to notify when a double-tap or a confirmed
* single-tap occur.
*/
public interface OnDoubleTapListener {
/**
* Notified when a single-tap occurs.
* <p>
* Unlike {@link OnGestureListener#onSingleTapUp(MotionEvent)}, this
* will only be called after the detector is confident that the user's
* first tap is not followed by a second tap leading to a double-tap
* gesture.
*
* @param e The down motion event of the single-tap.
* @return true if the event is consumed, else false
*/
boolean onSingleTapConfirmed(MotionEvent e);

/**
* Notified when a double-tap occurs.
*
* @param e The down motion event of the first tap of the double-tap.
* @return true if the event is consumed, else false
*/
boolean onDoubleTap(MotionEvent e);

/**
* Notified when an event within a double-tap gesture occurs, including
* the down, move, and up events.
*
* @param e The motion event that occurred during the double-tap gesture.
* @return true if the event is consumed, else false
*/
boolean onDoubleTapEvent(MotionEvent e);
}

/**
* The listener that is used to notify when a context click occurs. When listening for a
* context click ensure that you call {@link #onGenericMotionEvent(MotionEvent)} in
* {@link View#onGenericMotionEvent(MotionEvent)}.
*/
public interface OnContextClickListener {
/**
* Notified when a context click occurs.
*
* @param e The motion event that occurred during the context click.
* @return true if the event is consumed, else false
*/
boolean onContextClick(MotionEvent e);
}

这里我们主要是响应onFling()这个函数,然后判断当前是打开还是关闭状态,在根据快速滑动的手势来执行打开还是关闭的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
 @Override
public boolean onTouchEvent(MotionEvent ev) {
if (mGestureDetector.onTouchEvent(ev))//快速滑动触发了下面的就不要执行了
return true;

//....
}


//快速滑动
private GestureDetector.OnGestureListener mOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
//快速滑动回调
//打开的时候从右到左滑动关闭 关闭的时候从左往右打开
// Log.e("zzz", "velocityX->" + velocityX);
// >0 从左往右边滑动 <0 从右到左
if (mMenuIsOpen) {
if (velocityX < 0) {
closeMenu();
return true;
}
} else {
if (velocityX > 0) {
openMenu();
return true;
}
}
return super.onFling(e1, e2, velocityX, velocityY);
}
};

接下来处理menu打开状态下点击content关闭menu,这里我们需要用到onInterceptTouchEvent。当打开状态的时候,我们就把这个事件拦截,然后关闭菜单即可。但是这里有一个问题,当我们拦截了DOWN事件之后,后面的MOVE UP事件都会被拦截并且相应自身的onTouchEvent事件,所以这里我们需要添加一个判断值,判断是否拦截,然后让其onTouchEvent是否继续执行操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
isIntercept = false;
if (mMenuIsOpen && ev.getX() > mMenuWidth) {//打开状态 触摸右边关闭
isIntercept = true;//拦截的话就不执行自己的onTouchEvent
closeMenu();
return true;
}
return super.onInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent ev) {

if (isIntercept)//拦截的话就不执行自己的onTouchEvent
return true;
//...
}


根据我们提出需求,然后分析需求,再完成需求。这一步步我们慢慢进行渗透,最终完成效果,完成之后你会发现其实也就那么一回事。当我们有新需求的时候,我们应该不要恐惧,应该欣然乐观的接收,再慢慢分析,最终完成。这样的话我们才能提高我们的技术。

本文源码下载地址:https://github.com/ChinaZeng/CustomView

-------------The End-------------